이미지 압축기 제작 회고
강민규배경
2021년 10월 개발팀의 화두는 LCP를 줄이는 것이었습니다. LCP는 Largest Contentful Paint의 약자로, 페이지가 처음으로 로드를 시작한 시점을 기준으로 뷰포트 내에 있는 가장 큰 이미지 또는 텍스트 블록의 렌더링 시간입니다.
LCP가 낮아지면 SEO에 유리해지고, 고객경험이 조금 더 나아지는 긍정적인 효과가 있기 때문에 LCP는 낮으면 좋다 라고 볼 수 있는데요. 스파르타코딩클럽의 경우 필요 이상으로 큰 사이즈의 이미지가 LCP를 높이는데 기여하고 있다는 사실을 알게 되었습니다. 해당 문제와 관련하여 LCP를 낮추기위해 취할 수 있는 액션으로 lazy loading이나 이미지 리사이징 등이 있었고, 우선 이미지 리사이징을 적용하기로 했습니다.
선택지와 결정
이미지 리사이징을 하는 방법으로는 대개 두 가지가 있습니다. 요청 시 이미지를 리사이징해서 전달하는 온디맨드 방식, 그리고 미리 작은 사이즈로 이미지를 리사이징 해두고 사용하는 방식입니다. AWS는 Lambda Edge와 S3 트리거로 각각의 방식을 지원하고 있습니다.
적절한 방식이 무엇인지 선택하기 위해서 우선 적절한 방식이란 무엇인지를 정의해야했고, 저는 현재의 상황에서 적절한 방식이란 다음과 같다고 결론지었습니다.
- 적은 개발공수
- 배포 용이
- 적당한 화질
- 적은 용량
저는 위의 기준을 가지고 여러 방안들을 저울질했습니다.
딱 적당한 화질에 최대한 적은 용량으로 이미지를 리사이징하기에는 온디맨드 방식이 가장 적절했습니다. URL을 통해서 리사이징할 크기를 직접 지정할 수 있기 때문입니다. 그러나 온디맨드 방식에는 단점이 있습니다. 만약 이미지가 캐시에 적중하지 못할 경우, 이미지를 다시 리사이징하는 과정을 거쳐야 하기 때문에 로딩 시간이 더욱 길어질 수가 있습니다. 이것은 고객 경험에 부정적으로 작용할 것이므로, 저는 적당한 화질과 적당한 용량이 얼마나 중요한가에 대한 실험을 통해 온디맨드 방식의 적용 여부를 결정하기로 했습니다.
실험은 이러합니다. 넓이 400px 이미지가 필요하다고 했을 때, 적당히 600px 이미지를 가지고 오면 이미지가 깨지는 현상은 발생하지 않을 것입니다. 거기에 400px 이미지와 600px 이미지가 용량까지 비슷하다면, 400~600 픽셀 사이의 사이즈가 필요한 이미지는 모두 600픽셀 이미지를 사용해도 무방할 것입니다.
하나의 이미지를 놓고 400px, 600px 이미지로 리사이징을 해보았더니 용량 차이가 3~8% 정도 밖에 나지 않는다는 것을 확인했습니다. 응답시간 콘텐츠 다운로드 시간은 더 적은 수준으로 차이가 났습니다. 기존에 가지고 있던 이미지의 총 용량이 1GB를 넘지 않아서, 온디맨드 방식의 리사이징은 불필요하다고 판단했습니다.
이미지를 미리 리사이징 해두는 방식을 사용하기로 결정했고, 저는 S3 트리거를 알아보았습니다. 그러나 S3 트리거를 바로 적용하는 것이 현재 서비스의 아키텍처와 잘 맞지 않다는 것을 알게 되었습니다. 저희 개발팀에서는 새로운 이미지가 필요해질 때마다 S3에 업로드하는 것이 아니라, 배포 직전에 S3에 업로드하는 방식을 사용 중입니다.
해당 버킷을 static url로 정의하고 CDN은 해당 버켓을 보도록 되어있는데, S3에다가만 압축된 이미지를 사용하자니 개발환경에서 테스트 해보기가 힘들고, 개발환경에서 S3를 보게 하려면 손봐야할게 많았습니다. 또한 현재 구조에서 그냥 static 버킷에 s3 트리거를 적용하면 트리거가 무한히 발동되는 문제가 있어, 중간단계로 사용할 버킷이 하나 더 필요했고, 거기에 따라 환경설정이 바뀌어야하는 부분이 많았습니다. 이 정도가 되니 굳이 s3 트리거를 쓸 필요가 있을까 하는 의문이 들었습니다. 고객이 직접 s3에 이미지를 올리는 경우에나 압축된 이미지를 같이 저장하는 등의 방식으로 유용하지 우리 상황에는 잘 맞지 않는 것 같았습니다. 그래서 압축된 이미지의 용량이 얼마 되지 않는다면 굳이 s3 trigger를 사용하지 않기로 했습니다. 그래서 로컬에서 static 경로에 압축된 이미지를 저장해주는 프로그램을 개발하고 세가지 종류의 사이즈로 이미지를 압축했습니다. 그 결과 모든 사이즈의 이미지를 전부 합해도 기존 용량의 1.2배 수준밖에 되지 않았고, s3 trigger 선택지를 폐기했습니다.
결국, 직접 개발하기로 했습니다.
테스트 중에는 그냥 스크립트를 만들어 실행했지만, 배포나 이미지 추가 시 마다 스크립트를 수동으로 돌리는 것은 합리적이지 않았습니다. 그래서 개발자가 신경쓰지 않아도, 계속해서 리사이징 프로세스를 실행할 수 있도록 적당한 위치에서 스크립트를 자동으로 실행해야했습니다. 그래서 어플리케이션 빌드 단계에서 스크립트를 실행할 수 있도록 했습니다. 그러니 원하는대로 어플리케이션을 실행할 때마다 이미지를 리사이징해주었습니다. 그런데 매번 실행될 때마다 이미지를 총 500메가바이트 정도 되는 이미지를 리사이징하려고 하니 180초 가량의 시간이 걸렸습니다. 개발환경에서는 수정사항 저장 시 자동으로 어플리케이션을 다시 실행하도록 만들어져있어, 시시 때때로 3분이나 기다려야하는 문제가 발생하니, 리사이징 프로세스를 최적화하기로 했습니다.
최적화에 앞서 프로세스 중 어디서 가장 큰 병목이 발생하는가를 확인했습니다. 이미지를 리사이징하는 것은 pillow를 사용해서 구현했고, 한 이미지를 리사이징하는데는 측정되지도 않을 만큼 적은 시간을 쓰는 것을 확인했습니다. 알고리즘을 최적화 시키는데 시간을 쏟는 것은 부적절하다고 판단했고, 구조 상의 비효율을 제거하기로 했습니다. 기존의 구조는 모든 이미지 파일을 모두 리사이징하여 저장하는 로직이었지만, 사실 이미 리사이징을 했고, 파일이 변경되지도 않은 이미지의 경우는 굳이 리사이징 프로세스를 거치지 않아도 됩니다. 그래서 리사이징 프로세스 시 이미지 상태를 스냅샷으로 기록해두고, 다음 리사이징 프로세스에서는 대상 이미지를 저장된 스냅샷과 비교하여 스냅샷과 다른 이미지만 리사이징 하도록 프로세스를 개선했습니다.
스냅샷을 남길 때에는 이미지를 해싱하여 문자열로 저장하는 방식을 사용했습니다. 해싱된 결과물은 24자리의 16진수로 나타나기 때문에, 이미지의 용량이 어떻든 48바이트 문자열로 압축되는 특징이 있습니다. 그러나 압축 대상이 조금만 달라져도 해싱되는 값은 완전히 달라지기 때문에 두 대상의 일치 여부만을 확인하기에는 적절한 강력한 압축기법입니다.
불필요한 리사이징을 제거하고 나자, 이미지의 변경이 없을 때에는 3초정도의 시간이 걸렸습니다. 이미지 변경이 하나도 없음에도 3초나 시간이 걸렸던 이유는 여전히 파일 접근에 시간을 많이 소요했기 때문이었는데, 세부적인 로직을 고침으로써 변경사항이 없는 경우의 실행속도를 0.3~0.5초까지 낮출 수 있었습니다.
프로그램이 원하는 수준으로 동작하여 우리의 서비스에 적용하기로 했는데요, 여러 서브 도메인으로 제공되고있는 모든 서비스에 해당 프로그램을 적용하기 위해 처음으로 하려고 했던 것은 가장 고전적인 방식 “복사 붙여넣기” 였습니다. 그런데 이렇게 복사 붙여넣기를 할 경우에 이미지 리사이징 로직에 변경사항이 생기면 모든 깃 레포에 접근해서 일일히 변경사항을 수동 반영해줘야하는 문제점이 있습니다. 생긴지 얼마 안된 프로그램이라 얼마나 빈번하게 수정할 지 모르는 상황에서 이런 문제는 치명적입니다. 그래서 모든 서비스에서 해당 모듈을 적용하기 쉽게하기 위해 pip에 패키지로 등록을 해주고 각 레포에서 설치하여 쓸 수 있도록 했습니다. 버전관리만 잘해주면 쉽게 수정사항을 반영해줄 수 있게 되었습니다.
결과적으로 모든 서비스에 이미지 리사이징을 사용할 수 있게 되었는데요, 한 이미지로 보면 많으면 기존 이미지의 10% 수준까지 용량을 낮출 수 있었고, 그 결과로 LCP가 200ms 가량 줄었습니다. 기존의 2.7 s를 기준으로 생각해보면 7프로 줄였으니 꽤 결과가 좋다고 볼 수 있습니다. 앞으로도 이런 저런 액션을 통해 LCP를 더욱 줄일 예정입니다.